Skip to content

feat(admin): /admin/database 页面嵌入 pgAdmin iframe#301

Merged
longsizhuo merged 5 commits intomainfrom
feat/admin-database-iframe
Apr 17, 2026
Merged

feat(admin): /admin/database 页面嵌入 pgAdmin iframe#301
longsizhuo merged 5 commits intomainfrom
feat/admin-database-iframe

Conversation

@longsizhuo
Copy link
Copy Markdown
Member

背景

Neon → 自建 PG 迁移后,管理员需要一个入口做备份/恢复/查表/跑 SQL。
不想额外打开 `api.involutionhell.com:8082` 这种裸页面,直接内嵌进主站。

变更

  • 新增 `app/admin/database/page.tsx`
  • `/admin` 首页多一张 "数据库管理" 卡片

配套(在后端仓)

  • InvolutionHell/involutionhell-backend#12
    • docker-compose:pgAdmin 走 `SCRIPT_NAME=/admin/pgadmin`,端口收回 `127.0.0.1:8082`
    • Caddy(部署机 `/home/ubuntu/caddy-gateway/Caddyfile`)
      • `api.involutionhell.com/admin/pgadmin/*` → 127.0.0.1:8082
      • 剥 `X-Frame-Options`,下发 CSP `frame-ancestors 'self' involutionhell.com *.involutionhell.com *.vercel.app`

验证

  • `pnpm lint` 0 errors
  • `pnpm build` 成功,构建出 `/admin/database` 路由
  • 服务器上 `curl -I https://api.involutionhell.com/admin/pgadmin/\` 返回 302 + 正确 CSP 头
  • 在浏览器里实际打开 involutionhell.com/admin/database 确认 iframe 正常登录 pgAdmin(需要 Vercel 部署这个 PR 才能验证)

UX 说明

pgAdmin 自身的视觉风格跟主站割裂——这是方案 A 的内在缺点。用户明确说
"管理员不配享受好 UI",优先接通能力。后续若要统一风格,可以切方案 B
(自建 admin UI 调后端 API),或 C(常用操作自建 + 高级操作 iframe 兜底)。

管理员用一个主站入口进 pgAdmin 做备份/恢复/查表/跑 SQL,不再打开
api.involutionhell.com:8082 这种裸页面。pgAdmin 本身的 UI 风格跟主站不搭,
但用户明确说"管理员不配享受好 UI",优先接通能力。

- 新增 app/admin/database/page.tsx:AdminGuard 兜底权限,iframe src 走
  https://api.involutionhell.com/admin/pgadmin/(可由 NEXT_PUBLIC_PGADMIN_URL 覆盖)
- /admin 首页加"数据库管理"入口卡片

真实的权限/流量控制在后端 compose + Caddy 那边(见 involutionhell-backend#12):
Caddy 反向代理 /admin/pgadmin/* 到 127.0.0.1:8082,剥 X-Frame-Options,
下发 CSP frame-ancestors 放行 involutionhell.com 主域。
Copilot AI review requested due to automatic review settings April 17, 2026 20:46
@vercel
Copy link
Copy Markdown

vercel Bot commented Apr 17, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
involutionhell-github-io Ready Ready Preview, Comment Apr 17, 2026 10:10pm
website-preview Ready Ready Preview, Comment Apr 17, 2026 10:10pm

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds an admin-facing “Database” entrypoint in the Next.js admin area by embedding pgAdmin via an iframe, enabling basic DB operations (backup/restore/query) without leaving the main site UI.

Changes:

  • Add new /admin/database page that wraps an iframe (pgAdmin) with AdminGuard.
  • Add a new “数据库管理” card link on the /admin home page.
  • Minor formatting tweak in AdminCard ternary className for readability.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 4 comments.

File Description
app/admin/page.tsx Adds a new admin card linking to /admin/database; minor formatting change.
app/admin/database/page.tsx New admin page embedding pgAdmin via iframe with an optional NEXT_PUBLIC_PGADMIN_URL override.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread app/admin/database/page.tsx Outdated
Comment on lines +24 to +28
// pgAdmin 所在路径。默认打 production Caddy,dev 如果要本地联调可以用
// NEXT_PUBLIC_PGADMIN_URL 覆盖(比如指到 http://localhost:8082/admin/pgadmin/)。
const PGADMIN_URL =
process.env.NEXT_PUBLIC_PGADMIN_URL ??
"https://api.involutionhell.com/admin/pgadmin/";
Copy link

Copilot AI Apr 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

这里新增了硬编码的 production fallback URL(NEXT_PUBLIC_PGADMIN_URL 未设置时直接指向 https://api.involutionhell.com/...)。仓库文档约定里提到新代码不建议在生产环境使用硬编码 fallback,否则会把“漏配/配错环境变量”变成静默连到错误地址;在 preview/staging 或自建部署时也可能误连生产 pgAdmin。建议改成:要求必须显式配置 NEXT_PUBLIC_PGADMIN_URL(缺失时在页面上提示配置方式/返回空白),或至少根据 Vercel env/hostname 区分 preview 与 prod。

Copilot uses AI. Check for mistakes.
Comment thread app/admin/database/page.tsx Outdated
/**
* /admin/database — 数据库管理后台(iframe 嵌入 pgAdmin)。
*
* 权限:<AdminGuard required="admin"> 兜底,非 admin 直接 403;
Copy link

Copilot AI Apr 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

文件头注释里写的是 <AdminGuard required="admin">,但实际代码使用的是 <AdminGuard>(依赖 default required)。虽然行为一致,但注释和实现不一致会误导后续维护者;建议把注释改为与当前用法一致(或在代码里显式传 required="admin")。

Suggested change
* 权限:<AdminGuard required="admin"> 兜底,非 admin 直接 403
* 权限:<AdminGuard> 兜底,依赖默认 required="admin",非 admin 直接 403

Copilot uses AI. Check for mistakes.
Comment thread app/admin/database/page.tsx Outdated
Comment on lines +49 to +67
<p className="mt-2 text-xs text-neutral-600 dark:text-neutral-400 leading-relaxed">
下方嵌入的是 pgAdmin。首次进入要用{" "}
<code className="font-mono text-[11px] bg-neutral-200 dark:bg-neutral-800 px-1">
PGADMIN_EMAIL
</code>{" "}
/{" "}
<code className="font-mono text-[11px] bg-neutral-200 dark:bg-neutral-800 px-1">
PGADMIN_PASSWORD
</code>{" "}
登录(在{" "}
<code className="font-mono text-[11px] bg-neutral-200 dark:bg-neutral-800 px-1">
.env
</code>{" "}
里)。 左树自动预注册了 &ldquo;InvolutionHell (local)&rdquo;
连接,双击即连。 备份/恢复在数据库右键菜单里;定时备份落在{" "}
<code className="font-mono text-[11px] bg-neutral-200 dark:bg-neutral-800 px-1">
Storage → backups/
</code>
Copy link

Copilot AI Apr 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

页面文案提示用 PGADMIN_EMAIL / PGADMIN_PASSWORD 并写“在 .env 里”,但这两个变量在本仓库中并不存在/也不会暴露给浏览器端用户;对于管理员来说也很难“去看服务器 .env”。建议把文案改成更准确的操作指引(例如:由运维/后端部署环境配置 pgAdmin 登录账号密码;不确定时联系维护者),避免让人误以为需要在前端项目里配置或能直接查看。

Copilot uses AI. Check for mistakes.
Comment thread app/admin/database/page.tsx Outdated
Comment on lines +72 to +77
{/* iframe 占满剩余视口,便于操作。高度用 calc 减去 header 高度约 220px。 */}
<div className="flex-1 border-t border-[var(--foreground)]">
<iframe
src={PGADMIN_URL}
title="pgAdmin"
className="w-full h-[calc(100vh-220px)] min-h-[600px] border-0"
Copy link

Copilot AI Apr 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

iframe 高度通过 calc(100vh-220px) 依赖一个“约 220px”的 magic number;header 文案/字号/响应式变化后容易出现双滚动条或底部留白。既然外层用了 flex flex-col + flex-1,建议让 iframe 通过 flex 填满剩余空间(例如容器/iframe 使用 flex-1 h-full min-h-0 等),避免硬编码高度。

Suggested change
{/* iframe 占满剩余视口,便于操作。高度用 calc 减去 header 高度约 220px。 */}
<div className="flex-1 border-t border-[var(--foreground)]">
<iframe
src={PGADMIN_URL}
title="pgAdmin"
className="w-full h-[calc(100vh-220px)] min-h-[600px] border-0"
{/* iframe 通过 flex 填满 header 下方的剩余空间,避免依赖固定高度 magic number。 */}
<div className="flex-1 min-h-0 flex border-t border-[var(--foreground)]">
<iframe
src={PGADMIN_URL}
title="pgAdmin"
className="flex-1 w-full h-full min-h-0 border-0"

Copilot uses AI. Check for mistakes.
longsizhuo added a commit to InvolutionHell/involutionhell-backend that referenced this pull request Apr 17, 2026
背景:2026-04-17 把 Neon 切到自建 Docker PG 后,前端 Next.js 的 Prisma 还
指向 Neon,形成"前端写 Neon 旧库、后端读自建 PG"的脏数据分叉。方案 A:
前端 onFinish 不再直接写 DB,改调后端 API 由后端统一持久化。

- chat/repository/ChatHistoryRepository + JdbcChatHistoryRepository:@transactional
  原子写 chat 表 + user 消息 + assistant 消息;chat 用 ON CONFLICT upsert,
  匿名/登录混用同 chatId 时 COALESCE 保留已有 userId,避免被 NULL 覆盖
- chat/controller/ChatHistoryController:POST /api/chat/sessions/save,匿名也
  放行(SaTokenConfigure 加 notMatch),登录时自动从 sa-token 取 userId
- chat/dto/ChatTurnSaveRequest:一次请求三件事(chatId + userMessage + assistantMessage)
- schema.sql:补 "Chat" 和 "Message" DDL,让新部署能直接起库;名字带双引号
  保持与 Prisma schema 生成的大小写一致

配套前端 PR:InvolutionHell/involutionhell#301 改 app/api/chat/route.ts 的
onFinish 从 prisma.chat.upsert/message.create 换成 fetch 本接口。
背景:Neon → 自建 Docker PG 迁移后,前端 Prisma 还指向 Neon,AI 对话持久
化会写进旧库,和后端读自建 PG 分叉出脏数据。方案 A:把 chat + message 写
入挪到后端统一走,前端 onFinish 只发一次 HTTP。

- 删掉 import { prisma } from "@/lib/db",运行时再无 Prisma 依赖
- onFinish 原来三次 prisma 调用(chat upsert + user 消息 + assistant 消息)
  合并成一次 fetch(BACKEND_URL + "/api/chat/sessions/save")
- 后端接口匿名允许,登录时通过 satoken header 关联 userId,行为语义和原
  Prisma 版完全一致(匿名写 userId=NULL,登录补挂 userId)
- BACKEND_URL 未配或后端返回非 2xx 时 console.warn 不抛错,保持
  "持久化失败不阻塞对话流式返回"的原语义

Vercel AI SDK 流式路径(streamText / convertToModelMessages 等)完全未动,
前端 UX 无感知。

配套后端 PR:InvolutionHell/involutionhell-backend#13
iframe 嵌入两种嵌法都是坑:
  - 跨域嵌:pgAdmin session/CSRF cookie 走 SameSite=Lax,子域 iframe POST
    不带 cookie,登录永远报 "CSRF session token is missing"
  - 同源代理嵌:pgAdmin 会发绝对 URL 的重定向(host 是容器自己以为的值),
    浏览器跟着跳到 http://localhost:8082 变成 ERR_CONNECTION_REFUSED

管理员不高频用数据库,没必要为了 UI 嵌在主站里搭这些管道。改成一个大按钮,
target=_blank 打开 pgAdmin 自己的页面——cookie / CSRF 都在它自己域里,
一切正常工作。

同步删掉上一版临时加的 Next.js /admin/pgadmin/:path* rewrite。
longsizhuo added a commit to InvolutionHell/involutionhell-backend that referenced this pull request Apr 17, 2026
pgAdmin 自身跑在 SERVER_MODE=False(无登录页,desktop 模式)对公网暴露是个
critical 漏洞:扫到路径就能对生产 DB 跑 SQL。改方案:外层 Caddy 在
/admin/pgadmin/* 前插 forward_auth 钩子,钩本接口;接口用 @SaCheckRole("admin")
判定当前请求带的 cookie.satoken 对应的用户是不是 admin,通过就 200,否则 sa-token
自动抛异常走 401 / 403。

- 新建 admin/controller 包专门放跨业务的基础设施级接口(不塞进 events/controller
  避免语义混淆)
- 响应体故意空壳,Caddy 只看状态码不读 body
- superadmin 自动包含 admin 角色,天然放行,无需额外分支
- sa-token.is-read-cookie 默认开启,不用改 application.properties

配套前端改动:InvolutionHell/involutionhell#301 登录时把 satoken 同步写到
.involutionhell.com 域名 cookie,浏览器跨子域自动带。
配套 Caddy:/home/ubuntu/caddy-gateway/Caddyfile 改 handle 块,见 docs/database.md。
配合后端 /api/admin/pgadmin-check 和 Caddy forward_auth 的整条链:用户直连
api.involutionhell.com/admin/pgadmin/* 时浏览器不会主动发 satoken header,
必须靠 cookie 自动携带。

- 新加 syncTokenCookie(token):登录 / 刷新有效 session / 登出全部打点
  localhost 域不写 Domain(浏览器默认绑当前 host);
  生产写 Domain=.involutionhell.com 让主域 + 所有子域共享
  SameSite=Lax 刚好够——顶层导航 / 子资源 GET 都会带;跨站 POST 不带但我们
  也不需要(pgAdmin 的 CSRF 有自己的 cookie)
  Max-Age=2592000 与 sa-token.timeout 保持一致
- token 无效 / 登出时清掉 cookie,避免 stale 身份残留

服务端配套:InvolutionHell/involutionhell-backend#12
开发时访问 localhost:3010/admin/database 点按钮会直接打 prod
api.involutionhell.com,需要 cookie 但 localhost 登录时 cookie 写不到
.involutionhell.com 域,只能卡 401。

改成客户端挂载后读 window.location.hostname:
  - localhost / 127.0.0.1 → http://localhost:8082/admin/pgadmin/
    (要求开发者先 ssh -L 8082:127.0.0.1:8082 server 引端口)
  - 其他 → 原来的公网 URL(走 Caddy forward_auth 链)

NEXT_PUBLIC_PGADMIN_URL 仍然最高优先级,想覆盖任何时候都能覆盖。

useEffect 里 setState 走 Promise.resolve 异步化,绕开 React
"cascading renders" lint 规则。
longsizhuo added a commit to InvolutionHell/involutionhell-backend that referenced this pull request Apr 17, 2026
* feat(db): 自建 PostgreSQL + pgAdmin GUI + 自动备份,替代 Neon

Neon 免费月度 100 CU-h 配额耗尽后计算节点被暂停,业务全挂(2026-04-17 事故)。
改用本机 compose 起 postgres:18-alpine,附带 pgAdmin 作为带按钮的备份/恢复 GUI、
pg-backup 容器做每日自动快照(保留 30d/8w/12m)。

- docker-compose.yml 新增 pgadmin 与 pg-backup 服务,共享 pg-backups 命名卷
- pgAdmin 预注册 InvolutionHell 服务器,pgpass 通过只读挂载提供,不需每次手填
- pgpass 走 .gitignore,提供 pgpass.example 作模板
- docs/database.md 完整记录:日常使用、手动/定时备份、GUI/CLI 恢复流程、迁移历史

.env 的 PGHOST 已在服务器上从 Neon endpoint 改为 compose 服务名 postgres,
仓库中 .env 不入库故未一并提交,需按 docs/database.md 描述在部署机上同步更新。

* feat(db): pgAdmin 走 /admin/pgadmin 反向代理,供主站 iframe 嵌入

主站 involutionhell.com/admin/database 页面 iframe pgAdmin,管理员一个入口
解决。pgAdmin 端口从 0.0.0.0 收回到 127.0.0.1:8082,只接受 Caddy 从本机转发。

- SCRIPT_NAME=/admin/pgadmin 让 pgAdmin 自生成 URL 带正确前缀
- X_FRAME_OPTIONS 清空,由上游 Caddy 用 CSP frame-ancestors 控制
- WTF_CSRF_SSL_STRICT 关闭,避免跨子域 iframe 触发 CSRF 拒绝

Caddy 配置(独立文件 /home/ubuntu/caddy-gateway/Caddyfile,不在本仓库)同步
添加 /admin/pgadmin/* handle,剥 X-Frame-Options 并下发
CSP: frame-ancestors 'self' https://involutionhell.com https://*.involutionhell.com ...

docs/database.md 补充 iframe 架构与环境变量说明。

* feat(admin): /api/admin/pgadmin-check 作为 Caddy forward_auth 目标

pgAdmin 自身跑在 SERVER_MODE=False(无登录页,desktop 模式)对公网暴露是个
critical 漏洞:扫到路径就能对生产 DB 跑 SQL。改方案:外层 Caddy 在
/admin/pgadmin/* 前插 forward_auth 钩子,钩本接口;接口用 @SaCheckRole("admin")
判定当前请求带的 cookie.satoken 对应的用户是不是 admin,通过就 200,否则 sa-token
自动抛异常走 401 / 403。

- 新建 admin/controller 包专门放跨业务的基础设施级接口(不塞进 events/controller
  避免语义混淆)
- 响应体故意空壳,Caddy 只看状态码不读 body
- superadmin 自动包含 admin 角色,天然放行,无需额外分支
- sa-token.is-read-cookie 默认开启,不用改 application.properties

配套前端改动:InvolutionHell/involutionhell#301 登录时把 satoken 同步写到
.involutionhell.com 域名 cookie,浏览器跨子域自动带。
配套 Caddy:/home/ubuntu/caddy-gateway/Caddyfile 改 handle 块,见 docs/database.md。

* docs(db): pgAdmin 走 Caddy forward_auth + 前端 cookie 同步的完整架构说明

替换掉上一版 iframe-嵌入的描述(那版已经废弃,走不通 CSRF)。新架构:

- pgAdmin SERVER_MODE=False 无自身登录页,仅 127.0.0.1:8082 监听
- 唯一公网入口 api.involutionhell.com/admin/pgadmin/* 由 Caddy handle 块
  管控,前置 forward_auth 调 127.0.0.1:8080/api/admin/pgadmin-check
- 后端 @SaCheckRole("admin") 依赖 sa-token 从 cookie 读 token
- 前端 lib/use-auth.tsx 登录成功时把 satoken 同步写 .involutionhell.com
  域名 cookie,浏览器跨子域自动带

补 Caddy 配置片段 + 后端 Controller 片段 + 前端同步逻辑说明,
future reviewer 不用再去三处代码翻找。

* chore(pgadmin): 把 SERVER_MODE=False 的前提写在 compose 注释里 + 修 pg-backups 挂载

- 注释里显式写清楚:pgAdmin 跑 desktop 模式(无登录页)的安全前提是外层
  Caddy forward_auth 必须先到位。直接暴露 8082 是重大漏洞。
- pg-backups 卷从 /var/lib/pgadmin/storage/... 改挂到 /backups:ro。
  之前 SERVER_MODE=True 尝试时会因为 root 所有的备份目录触发
  'user does not have permission to read and write the specified storage directory'
  让 pgAdmin 无法启动;切回 desktop 模式后也避免继续污染 pgAdmin 自己的
  storage 路径。restore 对话框现在要手填 /backups/daily/xxx.dump 路径。
longsizhuo added a commit to InvolutionHell/involutionhell-backend that referenced this pull request Apr 17, 2026
背景:2026-04-17 把 Neon 切到自建 Docker PG 后,前端 Next.js 的 Prisma 还
指向 Neon,形成"前端写 Neon 旧库、后端读自建 PG"的脏数据分叉。方案 A:
前端 onFinish 不再直接写 DB,改调后端 API 由后端统一持久化。

- chat/repository/ChatHistoryRepository + JdbcChatHistoryRepository:@transactional
  原子写 chat 表 + user 消息 + assistant 消息;chat 用 ON CONFLICT upsert,
  匿名/登录混用同 chatId 时 COALESCE 保留已有 userId,避免被 NULL 覆盖
- chat/controller/ChatHistoryController:POST /api/chat/sessions/save,匿名也
  放行(SaTokenConfigure 加 notMatch),登录时自动从 sa-token 取 userId
- chat/dto/ChatTurnSaveRequest:一次请求三件事(chatId + userMessage + assistantMessage)
- schema.sql:补 "Chat" 和 "Message" DDL,让新部署能直接起库;名字带双引号
  保持与 Prisma schema 生成的大小写一致

配套前端 PR:InvolutionHell/involutionhell#301 改 app/api/chat/route.ts 的
onFinish 从 prisma.chat.upsert/message.create 换成 fetch 本接口。
longsizhuo added a commit to InvolutionHell/involutionhell-backend that referenced this pull request Apr 17, 2026
背景:2026-04-17 把 Neon 切到自建 Docker PG 后,前端 Next.js 的 Prisma 还
指向 Neon,形成"前端写 Neon 旧库、后端读自建 PG"的脏数据分叉。方案 A:
前端 onFinish 不再直接写 DB,改调后端 API 由后端统一持久化。

- chat/repository/ChatHistoryRepository + JdbcChatHistoryRepository:@transactional
  原子写 chat 表 + user 消息 + assistant 消息;chat 用 ON CONFLICT upsert,
  匿名/登录混用同 chatId 时 COALESCE 保留已有 userId,避免被 NULL 覆盖
- chat/controller/ChatHistoryController:POST /api/chat/sessions/save,匿名也
  放行(SaTokenConfigure 加 notMatch),登录时自动从 sa-token 取 userId
- chat/dto/ChatTurnSaveRequest:一次请求三件事(chatId + userMessage + assistantMessage)
- schema.sql:补 "Chat" 和 "Message" DDL,让新部署能直接起库;名字带双引号
  保持与 Prisma schema 生成的大小写一致

配套前端 PR:InvolutionHell/involutionhell#301 改 app/api/chat/route.ts 的
onFinish 从 prisma.chat.upsert/message.create 换成 fetch 本接口。
@longsizhuo longsizhuo merged commit 38bbaaa into main Apr 17, 2026
7 checks passed
@longsizhuo longsizhuo deleted the feat/admin-database-iframe branch April 17, 2026 22:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants